Skip to content

V2: put the open conversation back in the address bar - #1384

Merged
Paul Lizer (paullizer) merged 3 commits into
paullizer-react-v2-uifrom
paullizer-conversation-id-url-support
Sep 2, 2026
Merged

V2: put the open conversation back in the address bar#1384
Paul Lizer (paullizer) merged 3 commits into
paullizer-react-v2-uifrom
paullizer-conversation-id-url-support

Conversation

@paullizer

Copy link
Copy Markdown
Contributor

Adds conversation deep linking to the V2 interface, so a link opens the conversation it names.

Reported against https://…/chats?conversationId=67553a4a-… — that link works in the classic interface but had no V2 equivalent.

The gap

The classic interface has supported this since v0.237.001: chat-onload.js reads ?conversationId= (or the older ?conversation_id=) on load, and chat-conversations.js writes the open conversation back with history.replaceState.

The V2 SPA had neither half. useSearchParams appeared exactly once in all of V2, in SettingsPage.tsx for ?tab=. So /v2/chat never named what was open, copying the address bar shared nothing, and a refresh landed in an empty chat.

Approach

The read and the write are deliberately asymmetric, and that is the whole design: the URL is read once, during the chat page's first render, and written on every change of the open conversation after that.

Two ordering hazards are handled explicitly, because both fail silently:

  • The incoming id is captured in a lazy useState initialiser, which runs during the first render. The write effect also runs on mount and would otherwise strip the parameter before the read effect ever saw it.
  • The write is held until the open settles, either way, so it never observes the moment before the conversation exists and clears the parameter that named it.

replace rather than a push, matching classic's replaceState: opening ten conversations should not put ten entries behind the back button.

Changes

File What
lib/conversationUrl.ts (new) Parameter names and rules in one place. Both spellings read, only the canonical one written. A "no change needed" return is what keeps the sync effect from re-entering itself.
pages/ChatPage.tsx useConversationUrlSync()
stores/chatStore.ts openLinkedConversation(), plus a conversation-list backfill
components/layout/Sidebar.tsx "Back to classic UI" carries the conversation across

Both spellings are accepted on arrival because the server emits both — conversationId from functions_notifications.py and functions_workflow_runner.py, conversation_id from route_frontend_chats.py and functions_documents.py. An incoming legacy link is rewritten to the canonical spelling, so a URL never carries both.

First send, fork, New Chat and delete all came free, since they all end at activeConversationId.

A conversation reached by link need not be in the loaded list — it can be older than the first feed page (FEED_PAGE_SIZE = 30) or hidden (include_hidden defaults false). Its row is built from the metadata the chat page already fetches, so the rail highlights it rather than the header reading "New chat" for a thread that is plainly open.

Worth a reviewer's attention

The first cut detected a bad link by checking messagesError after loading. That misses deleted conversations: /api/get_messages catches the not-found LookupError and answers {'messages': []} with a 200 (route_backend_conversations.py:1068). A deleted conversation would have opened as an empty chat, kept its id in the address bar, and stayed the target of the next message sent.

Existence is now checked against /api/conversations/<id>/metadata, which answers 404 when the conversation is gone and 403 when it is someone else's — the question actually being asked. That costs one extra request on a path that runs once per page load.

Scope

V2 only, as agreed. Server-generated links (notifications, workflow runs, document sources) still point at the classic /chats and are not made interface-aware.

Validation

  • npm run typecheck and npm run build clean
  • 19 runtime behaviour checks on the pure helpers, including proving normalisation converges so the sync effect cannot loop
  • New functional_tests/test_v2_conversation_deep_link.py: 8/8
  • Full V2 suite (22 files) plus test_docs_app_surface_coverage.py and test_docs_site_quality.py: 0 failures

Also in this change

config.py0.261.028, a "Linking to a conversation" section in REACT_V2_UI.md with a row in its testing table, and a release-notes entry.

No docs/_data/app_surface.yml regeneration needed: scripts/build_docs_inventory.py scans application/single_app only, and this adds no enable_* key, admin tab, action plugin or chat control.

The classic interface has supported linking to a conversation since v0.237.001:
chat-onload.js reads ?conversationId= (or the older ?conversation_id=) on load, and
chat-conversations.js writes the open conversation back with replaceState. The V2 SPA
had neither half, so /v2/chat never named what was open, copying the URL shared
nothing, and a refresh landed in an empty chat.

The read and the write are deliberately asymmetric: the URL is read once, during the
chat page's first render, and written on every change of the open conversation after
that. The incoming id is captured in a lazy useState initialiser because the write
effect also runs on mount and would otherwise strip the parameter before the read
effect ever saw it, and the write is held until the open settles so it cannot observe
the moment before the conversation exists.

Both parameter spellings are accepted, since the server emits both, and only the
canonical one is written. A conversation older than the first feed page, or hidden,
gets a list row built from the metadata already being fetched, so the rail highlights
it instead of the header reading "New chat".

Existence is checked against the metadata endpoint rather than inferred from the
message load. /api/get_messages is not an existence check: it turns a not-found
conversation into {'messages': []} with a 200, so a deleted conversation would have
opened as an empty chat, kept its id in the URL, and stayed the target of the next
message sent.

Back to classic UI now carries the conversation across.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The base moved on twice while this was open: PR #1383 (web search and AI
notices) and PR #1386 (inline image proposals). Both conflicts were version
bookkeeping rather than code -- chatStore.ts auto-merged, and the two branches
touch different parts of it.

VERSION goes to 0.261.030. 0.261.028 was taken by the notices work and
0.261.029 by the image proposals, so this claims the next free number rather
than reusing one; the mermaid branch is taking 0.261.031. The release note
entry moves to a new 0.261.030 section above both of theirs, which are left
intact, and the feature doc keeps both its testing-table rows.

Verified after resolving: no leftover markers, the feature doc differs from the
base only by additions, the full V2 suite (24 Python files plus the inline
image proposal runtime checks) passes, both docs tests pass, and the bundle
builds against the merged dependencies.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
PR #1385 (Mermaid diagram rendering) landed first and took 0.261.031, so this
branch's 0.261.030 was no longer ahead of the base and both sides claimed a
changelog section at the top.

The version moves to 0.261.032 in all four places that carry it -- config.py,
the release note section heading, the test's docstring header and its
assert_app_version_at_least floor -- rather than adopting the base's 031 and
leaving this feature documented under a number that never shipped containing
it. The base's VERSION must strictly increase on every merge into it; keeping
031 would have left two different base commits indistinguishable by version,
which is the one thing a running deployment can report about itself. The gap
where 030 was costs nothing.

Note that 032 is on neither side of the conflict: this is not a pick-one
resolution, which is the shape most easily resolved by reflex.

Changelog reads 032 -> 031 -> 029 -> 028, with the diagram sections intact and
this branch's delta being additions only. Verified: no leftover markers, no
lingering 030 reference anywhere, the V2 suite (24 Python files plus the
inline image proposal runtime checks) passes, the two diagram tests arriving
with the base pass, both docs tests pass, and the bundle builds.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit 88c95f3 into paullizer-react-v2-ui Sep 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant